avoid overriding <PreferredStartingEndpoint> on call start A bug report showed it was possible for the user to select the pre-call audio endpoint EARPIECE and when starting an AUDIO call, the audio route would switch to SPEAKER. Upon investigation, it looks like the setVideoState update can cause the platform telecom audio manager to auto route to SPEAKER. To patch this, in the jetpack layer, core-telecom will catch this case and reswitch the audio route back to the user selected preferred endpoint ------------------------------------------------- As a side note, this was only reproducible on Andoid 13 and lower BUT to keep consistency across all levels, both CallSession*s will contain the new logic Bug: 394737391 Test: manual Change-Id: I62871b5072d87930238b1fc920d28caaaf79a38a
diff --git a/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/Constants.kt b/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/Constants.kt index aff70cf..43b8474 100644 --- a/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/Constants.kt +++ b/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/Constants.kt
@@ -33,6 +33,7 @@ // Intent Extras const val EXTRA_SIMULATED_NUMBER: String = "simulated_number" const val EXTRA_REMOTE_USER_NAME: String = "name" + const val EXTRA_IS_VIDEO: String = "isVideo" // Deep Link Base URI const val DEEP_LINK_BASE_URI = "androidx.core.telecom.reference://"
diff --git a/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/IncomingCallReceiver.kt b/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/IncomingCallReceiver.kt index 5701ce4..8f6a0fed 100644 --- a/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/IncomingCallReceiver.kt +++ b/core/core-telecom/integration-tests/referenceapp/src/main/java/androidx/core/telecom/reference/IncomingCallReceiver.kt
@@ -24,6 +24,7 @@ import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallAttributesCompat.Companion.DIRECTION_INCOMING import androidx.core.telecom.reference.Constants.ACTION_NEW_INCOMING_CALL +import androidx.core.telecom.reference.Constants.EXTRA_IS_VIDEO import androidx.core.telecom.reference.Constants.EXTRA_REMOTE_USER_NAME import androidx.core.telecom.reference.Constants.EXTRA_SIMULATED_NUMBER import androidx.core.telecom.reference.view.loadPhoneNumberPrefix @@ -35,7 +36,8 @@ * Example broadcast to start a new incoming call: * ``` * adb shell am broadcast -a androidx.core.telecom.reference.NEW_INCOMING_CALL - * --es simulated_number "123" --es name "John Smith" androidx.core.telecom.reference + * --es simulated_number "123" --es name "John Smith" --ez isVideo true + * androidx.core.telecom.reference * ``` */ class IncomingCallReceiver : BroadcastReceiver() { @@ -57,6 +59,7 @@ private fun handleIncomingCall(context: Context, intent: Intent) { val incomingNumber = intent.getStringExtra(EXTRA_SIMULATED_NUMBER) val remoteName = intent.getStringExtra(EXTRA_REMOTE_USER_NAME) + val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false) if (incomingNumber == null || remoteName == null) { Log.e(TAG, "Incoming call intent missing required extras (number or name)") @@ -64,7 +67,7 @@ } val notificationId = getNextNotificationId() - val attributes = getCallAttributes(context, incomingNumber, remoteName) + val attributes = getCallAttributes(context, incomingNumber, remoteName, isVideo) val callRepository = (context.applicationContext as? VoipApplication)?.callRepository // add the call to Core-Telecom in parallel to creating the call-style notification @@ -75,11 +78,7 @@ callNotificationManager = CallNotificationManager(context) Log.d(TAG, "Generated notification ID [$notificationId] for incoming call.") - postNotification( - callNotificationManager, - notificationId, - getCallAttributes(context, incomingNumber, remoteName), - ) + postNotification(callNotificationManager, notificationId, attributes) } private fun postNotification( @@ -100,8 +99,20 @@ } } - private fun getCallAttributes(c: Context, num: String?, name: String?): CallAttributesCompat { + private fun getCallAttributes( + c: Context, + num: String?, + name: String?, + isVideo: Boolean, + ): CallAttributesCompat { val address = Uri.parse(loadPhoneNumberPrefix(c) + num) - return CallAttributesCompat(name.toString(), address, DIRECTION_INCOMING) + return CallAttributesCompat( + name.toString(), + address, + DIRECTION_INCOMING, + callType = + if (isVideo) CallAttributesCompat.Companion.CALL_TYPE_VIDEO_CALL + else CallAttributesCompat.Companion.CALL_TYPE_AUDIO_CALL, + ) } } diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt index c1da396..fb22791 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSession.kt
@@ -34,6 +34,7 @@ import androidx.core.telecom.internal.utils.EndpointUtils.Companion.getSpeakerEndpoint import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isBluetoothAvailable import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isEarpieceEndpoint +import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isSpeakerEndpoint import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isWiredHeadsetOrBtEndpoint import androidx.core.telecom.internal.utils.EndpointUtils.Companion.maybeRemoveEarpieceIfWiredEndpointPresent import java.util.function.Consumer @@ -73,6 +74,21 @@ private val mIsAvailableEndpointsSet = CompletableDeferred<Unit>() private val mIsCurrentlyDisplayingVideo = attributes.isVideoCall() internal val mJetpackToPlatformCallEndpoint: HashMap<ParcelUuid, CallEndpoint> = HashMap() + /** + * Stores the audio endpoint that was initially preferred by the client when the call was + * started. This is used to detect and correct scenarios where the platform might incorrectly + * override this preference at the beginning of the call. + */ + private var mPreferredStartingCallEndpoint: CallEndpointCompat? = null + /** + * Flag to ensure that the logic to [avoidSpeakerOverrideOnCallStart] is only attempted once + * after the initial conditions are met (i.e., a previous endpoint is known). This prevents + * repeated attempts to correct the endpoint if other changes occur. It is set to `true` within + * [avoidSpeakerOverrideOnCallStart] after the first invocation where `prevEndpoint` is not + * null, indicating the initial audio route stabilization phase (for this specific check) has + * been processed. + */ + private var mWasPreferredOverrideChecked: Boolean = false init { CoroutineScope(coroutineContext).launch { @@ -154,6 +170,7 @@ Log.i(TAG, "onCallEndpointChanged: mCurrentCallEndpoint was set") } maybeSwitchToSpeakerOnHeadsetDisconnect(mCurrentCallEndpoint!!, previousCallEndpoint) + avoidSpeakerOverrideOnCallStart(previousCallEndpoint, mCurrentCallEndpoint) // clear out the last user requested CallEndpoint. It's only used to determine if the // change in current endpoints was intentional for maybeSwitchToSpeakerOnHeadsetDisconnect if (mLastClientRequestedEndpoint?.type == endpoint.endpointType) { @@ -161,6 +178,99 @@ } } + /** + * Addresses a specific issue where the Telecom platform might erroneously switch the audio + * route to SPEAKER immediately after the call starts, even if the user specified a + * {@link #mPreferredStartingCallEndpoint}. + * + * If conditions are met, this method attempts to switch the audio route back to the preferred + * audio endpoint. This logic is guarded by {@link #mWasPreferredOverrideChecked} to ensure it + * only runs once when the `prevEndpoint` first becomes available, targeting an early call setup + * phase. + * + * @param prevEndpoint The audio endpoint active before the current change. + * @param nextEndpoint The new audio endpoint that has just become active. + */ + fun avoidSpeakerOverrideOnCallStart( + prevEndpoint: CallEndpointCompat?, + nextEndpoint: CallEndpointCompat?, + ) { + if (mWasPreferredOverrideChecked) { + Log.d(TAG, "avoidSpeakerOverrideOnCallStart: Already checked." + "Skipping.") + return + } + + // We need a prevEndpoint to reliably determine the transition. + // If prevEndpoint is null, it means this is likely the very first endpoint update, + // or the state is not yet stable enough for this specific check. + // Wait for a subsequent onCallEndpointChanged callback where prevEndpoint is available. + if (prevEndpoint == null) { + Log.d( + TAG, + "avoidSpeakerOverrideOnCallStart: prevEndpoint is null, waiting for" + + " more context before checking.", + ) + return + } + + // Since prevEndpoint is now non-null, we are proceeding with the one-time check. + // Set the flag to true immediately to ensure this block of logic runs at most once + // under these stable conditions (prevEndpoint is known). + mWasPreferredOverrideChecked = true + Log.i( + TAG, + "avoidSpeakerOverrideOnCallStart: Evaluating. " + + "mPreferredStartingCallEndpoint=[$mPreferredStartingCallEndpoint], " + + "mLastClientRequestedEndpoint=[$mLastClientRequestedEndpoint], " + + "prevEndpoint=[$prevEndpoint], " + + "nextEndpoint=[$nextEndpoint]", + ) + + // Check 1: Did the user explicitly request the current 'nextEndpoint' if it's SPEAKER? + // `mLastClientRequestedEndpoint` would have been set by your app calling + // `requestEndpointChange`. This value is cleared after the platform confirms the change + // in `onCallEndpointChanged`, so it correctly reflects the *intent leading to the + // current `nextEndpoint`*. + if ( + mLastClientRequestedEndpoint != null && + isSpeakerEndpoint( + mLastClientRequestedEndpoint + ) && // User explicitly asked for SPEAKER + isSpeakerEndpoint(nextEndpoint) // And the current endpoint IS SPEAKER + ) { + Log.i( + TAG, + "avoidSpeakerOverrideOnCallStart: User explicitly requested SPEAKER " + + "($mLastClientRequestedEndpoint). Current endpoint is $nextEndpoint. " + + "Assuming intentional. No override.", + ) + return // Do not proceed with automatic override + } + + // Check 2: bug fix logic - an unexpected switch from PreferredStartingCallEndpoint + // to SPEAKER. This runs if the change to SPEAKER was not an explicit user request + // for SPEAKER. + if ( + mPreferredStartingCallEndpoint != null && + mPreferredStartingCallEndpoint == prevEndpoint && + mPreferredStartingCallEndpoint != nextEndpoint && + isSpeakerEndpoint(nextEndpoint) // Current endpoint is SPEAKER + ) { + CoroutineScope(coroutineContext).launch { + Log.i( + TAG, + "avoidSpeakerOverrideOnCallStart: Unwanted switch from preferred" + + "starting endpoint to SPEAKER detected. " + + "Requesting switch back to preferred: $mPreferredStartingCallEndpoint", + ) + // Request change back to the originally preferred endpoint + mPreferredStartingCallEndpoint?.let { requestEndpointChange(it) } + } + } else { + Log.d(TAG, "avoidSpeakerOverrideOnCallStart: Conditions for override not met.") + } + } + override fun onAvailableCallEndpointsChanged(endpoints: List<CallEndpoint>) { // due to the [CallsManager#getAvailableStartingCallEndpoints] API, endpoints the client // has can be different from the ones coming from the platform. Hence, a remapping is needed @@ -193,6 +303,7 @@ * starting CallEndpointCompat should be switched based on the call properties or user request. */ suspend fun maybeSwitchStartingEndpoint(preferredStartingCallEndpoint: CallEndpointCompat?) { + mPreferredStartingCallEndpoint = preferredStartingCallEndpoint if (preferredStartingCallEndpoint != null) { switchStartingCallEndpointOnCallStart(preferredStartingCallEndpoint) } else { diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt index ae6b4ea3..b96b007 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/CallSessionLegacy.kt
@@ -41,6 +41,7 @@ import androidx.core.telecom.internal.utils.EndpointUtils.Companion.getSpeakerEndpoint import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isBluetoothAvailable import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isEarpieceEndpoint +import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isSpeakerEndpoint import androidx.core.telecom.internal.utils.EndpointUtils.Companion.isWiredHeadsetOrBtEndpoint import androidx.core.telecom.internal.utils.EndpointUtils.Companion.maybeRemoveEarpieceIfWiredEndpointPresent import androidx.core.telecom.internal.utils.EndpointUtils.Companion.toCallEndpointCompat @@ -85,6 +86,15 @@ private val mCallSessionLegacyId: Int = CallEndpointUuidTracker.startSession() private var mGlobalMuteStateReceiver: MuteStateReceiver? = null private val mDialingOrRingingStateReached = CompletableDeferred<Unit>() + /** + * Flag to ensure that the logic to {@link #avoidSpeakerOverrideOnCallStart} is only attempted + * once after the initial conditions are met (i.e., a previous endpoint is known). This prevents + * repeated attempts to correct the endpoint if other changes occur. It is set to `true` within + * {@link #avoidSpeakerOverrideOnCallStart} after the first invocation where `prevEndpoint` is + * not null, indicating the initial audio route stabilization phase (for this specific check) + * has been processed. + */ + private var mWasPreferredOverrideChecked: Boolean = false init { if (isBuildAtLeastP()) { @@ -211,6 +221,10 @@ // On the first call audio state change, determine if the platform started on the // correct audio route. Otherwise, request an endpoint switch. switchStartingCallEndpointOnCallStart(mAvailableCallEndpoints) + // On initial call start, if the user selected a preferred endpoint, do not override + // with speaker! + avoidSpeakerOverrideOnCallStart(mPreviousCallEndpoint, mCurrentCallEndpoint) + // In the event the users headset disconnects, they will likely want to continue the // call via the speakerphone if (mCurrentCallEndpoint != null) { @@ -251,6 +265,99 @@ } /** + * Addresses a specific issue where the Telecom platform might erroneously switch the audio + * route to SPEAKER immediately after the call starts, even if the user specified a + * {@link #mPreferredStartingCallEndpoint}. + * + * If conditions are met, this method attempts to switch the audio route back to the preferred + * audio endpoint. This logic is guarded by {@link #mWasPreferredOverrideChecked} to ensure it + * only runs once when the `prevEndpoint` first becomes available, targeting an early call setup + * phase. + * + * @param prevEndpoint The audio endpoint active before the current change. + * @param nextEndpoint The new audio endpoint that has just become active. + */ + fun avoidSpeakerOverrideOnCallStart( + prevEndpoint: CallEndpointCompat?, + nextEndpoint: CallEndpointCompat?, + ) { + if (mWasPreferredOverrideChecked) { + Log.d(TAG, "avoidSpeakerOverrideOnCallStart: Already checked." + "Skipping.") + return + } + + // We need a prevEndpoint to reliably determine the transition. + // If prevEndpoint is null, it means this is likely the very first endpoint update, + // or the state is not yet stable enough for this specific check. + // Wait for a subsequent onCallEndpointChanged callback where prevEndpoint is available. + if (prevEndpoint == null) { + Log.d( + TAG, + "avoidSpeakerOverrideOnCallStart: prevEndpoint is null, waiting for" + + " more context before checking.", + ) + return + } + + // Since prevEndpoint is now non-null, we are proceeding with the one-time check. + // Set the flag to true immediately to ensure this block of logic runs at most once + // under these stable conditions (prevEndpoint is known). + mWasPreferredOverrideChecked = true + Log.i( + TAG, + "avoidSpeakerOverrideOnCallStart: Evaluating. " + + "mPreferredStartingCallEndpoint=[$preferredStartingCallEndpoint], " + + "mLastClientRequestedEndpoint=[$mLastClientRequestedEndpoint], " + + "prevEndpoint=[$prevEndpoint], " + + "nextEndpoint=[$nextEndpoint]", + ) + + // Check 1: Did the user explicitly request the current 'nextEndpoint' if it's SPEAKER? + // `mLastClientRequestedEndpoint` would have been set by your app calling + // `requestEndpointChange`. This value is cleared after the platform confirms the change + // in `onCallEndpointChanged`, so it correctly reflects the *intent leading to the + // current `nextEndpoint`*. + if ( + mLastClientRequestedEndpoint != null && + isSpeakerEndpoint( + mLastClientRequestedEndpoint + ) && // User explicitly asked for SPEAKER + isSpeakerEndpoint(nextEndpoint) // And the current endpoint IS SPEAKER + ) { + Log.i( + TAG, + "avoidSpeakerOverrideOnCallStart: User explicitly requested SPEAKER " + + "($mLastClientRequestedEndpoint). Current endpoint is $nextEndpoint. " + + "Assuming intentional. No override.", + ) + return // Do not proceed with automatic override + } + + // Check 2: bug fix logic - an unexpected switch from PreferredStartingCallEndpoint + // to SPEAKER. This runs if the change to SPEAKER was not an explicit user request + // for SPEAKER. + if ( + preferredStartingCallEndpoint != null && + preferredStartingCallEndpoint == prevEndpoint && + preferredStartingCallEndpoint != nextEndpoint && + isSpeakerEndpoint(nextEndpoint) // Current endpoint is SPEAKER + ) { + CoroutineScope(coroutineContext).launch { + Log.i( + TAG, + "avoidSpeakerOverrideOnCallStart: Unwanted switch from preferred" + + "starting endpoint to SPEAKER detected. " + + "Requesting switch back to preferred: $preferredStartingCallEndpoint", + ) + // Request change back to the originally preferred endpoint + requestEndpointChange(preferredStartingCallEndpoint) + } + } else { + Log.d(TAG, "avoidSpeakerOverrideOnCallStart: Conditions for override not met.") + } + } + + /** * Due to the fact that OEMs may diverge from AOSP telecom platform behavior, Core-Telecom needs * to ensure that video calls start with speaker phone if the earpiece is the initial audio * route. diff --git a/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt b/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt index 52c9c74..8978d67 100644 --- a/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt +++ b/core/core-telecom/src/main/java/androidx/core/telecom/internal/utils/EndpointUtils.kt
@@ -172,6 +172,13 @@ return endpoint.type == CallEndpointCompat.TYPE_EARPIECE } + fun isSpeakerEndpoint(endpoint: CallEndpointCompat?): Boolean { + if (endpoint == null) { + return false + } + return endpoint.type == CallEndpointCompat.TYPE_SPEAKER + } + fun isWiredHeadsetOrBtEndpoint(endpoint: CallEndpointCompat?): Boolean { if (endpoint == null) { return false